Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add APQ support #555

Merged
merged 18 commits into from
May 21, 2024
Merged

Add APQ support #555

merged 18 commits into from
May 21, 2024

Conversation

sungam3r
Copy link
Member

fixes #548

@rose-a @Shane32 please review, especially TODO note about extensions type

Also I would like to note that design of GraphQL client from this repo allows to send arbitrary queries to the server since caller may build arbitrary strings, for example, ones with concatenated literals with different values

query { user(id: "1234567") { name } }

Server cache for APQ may be polluted. In my in-house GraphQL client I do not allow to pass arbitrary strings. Instead this client autogenerates query based on used arguments in autogenerated methods and places all values into variables so the query text itself is already constant value like

query q($userId: String!) { user(id: $userId) { name } }

Nevertheless, I'm fine to add general possibility to use APQ here.

@sungam3r sungam3r requested review from rose-a and Shane32 April 24, 2023 17:07
@sungam3r sungam3r self-assigned this Apr 24, 2023
@sungam3r sungam3r added the enhancement New feature or request label Apr 24, 2023
Comment on lines 161 to 162
else if (response.Errors.Any(error => string.Equals(error.Message, "PersistedQueryNotSupported") ||
response.Errors.Any(error => string.Equals(error.Message, "GraphQL query is missing.")))) // GraphQL.NET specific error message
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if it's another GraphQL server which has a slightly different response? This repo is not exclusively for GraphQL servers that either (1) support APQ or (2) are GraphQL.NET. I'm not sure, but even GraphQL.NET might return different messages for older versions.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know. It's more like demo to discuss. My implementation has more conditions to consider - http status codes, graphql error codes in extensions, etc. - but I minimized them to minimum to make code clean. I'm open to suggestions.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apollo uses a delegate to determine if it should disable persistent queries for the session...

disable: a function which takes an ErrorResponse (see below) and returns a boolean to disable any future persisted queries for that session. This defaults to disabling on PersistedQueryNotSupported or a 400 or 500 http error.

We'd have to handle the GraphQLHttpRequestException from SendHttpRequestAsync there, too.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PersistedQueryNotSupported error is propably the most unlikely... because then the server needs to know what a persisted query looks like which means its most likely supported but explicitly turned off..

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the error handling is a bit of a pain, there doesn't seem to be any error code or structured response indicating missing or incorrect hash, I'm afraid we must just link currently known responses, for particular implementations to behaviors we want. Maybe at one point they will come up with a generic way to report such errors.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'd have to handle the GraphQLHttpRequestException from SendHttpRequestAsync there, too

Still in TODO.

@Shane32
Copy link
Member

Shane32 commented Apr 24, 2023

please review, especially TODO note about extensions type

I use my own GraphQL client implementation, and it is based on generics; I wouldn't have a dictionary myself. Although the serializer would handle a dictionary if it was passed it. The same may be true here (?)

@sungam3r
Copy link
Member Author

Problem with extensions is in design. What if someone set request.Extensions = new Person() before passing request into client? How to deal with arbitrary initialized object? How to add new data into it? Ping @rose-a , please comment.

@rose-a
Copy link
Collaborator

rose-a commented Apr 25, 2023

@Shane32 how would you handle a generic extensions type when a user want's to use his own extension in combination with the integrated APQ feature?

I think making request.Extension a Dictionary<string, object?> would be sensible in that it would provide a relatively safe way of providing integrated functionality like APQ and allowing the user to add other extensions without bothering too much with the integrated stuff (of course you can still provoke conflicts, but the chances are a lot smaller...). But there will propably be someone who's just sending a string via the extensions "object" right now who will complain anyway 😉 .

It also fits the GraphQLOverHTTP Spec Draft where its specified as a map...

@sungam3r
Copy link
Member Author

Then I suggest to open another PR changing extensions property to Dictionary<string, object?>. OK?

Also note that the problem is not only between built-in functionality (like APQ or something else) and user extensions. User may use graphql client in such a way that different modules may want to set their own extensions and conflict on shared object. Dictionary (map) allows easily eliminate conflict.

@Shane32
Copy link
Member

Shane32 commented Apr 25, 2023

@Shane32 how would you handle a generic extensions type when a user want's to use his own extension in combination with the integrated APQ feature?

I don't use this library, but for my library, I'd serialize the user's anonymous object to a JsonElement and append the APQ data.

Let's ask ChatGPT for a sample:

ChatGPT 4 request

Write a C# function with this signature:

Dictionary<string, object> AddAPQSupport(object extensions, int version, string sha256Hash)

The function should serialize 'extensions' to a JsonElement, expecting a map, and add two keys to the map, "version" and "sha256Hash". If extensions is null it should create a new map

ChatGPT 4 response

using System;
using System.Collections.Generic;
using System.Text.Json;

public class APQSupport
{
    public static Dictionary<string, object> AddAPQSupport(object extensions, int version, string sha256Hash)
    {
        Dictionary<string, object> resultMap;

        if (extensions == null)
        {
            resultMap = new Dictionary<string, object>();
        }
        else
        {
            try
            {
                string jsonString = JsonSerializer.Serialize(extensions);
                resultMap = JsonSerializer.Deserialize<Dictionary<string, object>>(jsonString);
            }
            catch (Exception)
            {
                throw new ArgumentException("Invalid 'extensions' input. It must be a serializable map.");
            }
        }

        resultMap["version"] = version;
        resultMap["sha256Hash"] = sha256Hash;

        return resultMap;
    }
}

This C# function creates a new map if the extensions parameter is null or serializes the input to a JsonElement and converts it to a Dictionary. Then, it adds "version" and "sha256Hash" keys to the map. If the input is not a serializable map, it throws an ArgumentException.

@Shane32
Copy link
Member

Shane32 commented Apr 25, 2023

Keep in mind that a dictionary works just as well to be fed into this as an anonymous type. Now in my own library, I also support pre-encoded JSON strings as inputs to extensions/variables, not just anonymous types. Just because we are having fun here, let's have ChatGPT add that feature, and also optimize it a bit:

Request 2

change it so that if a string is passed in to extensions, it is assumed to be json and deserialized

Request 3

utilize the SerializeToElement method so it does not need to be serialized to a string

Final ChatGPT 4 output

using System;
using System.Collections.Generic;
using System.Text.Json;

public class APQSupport
{
    public static Dictionary<string, object> AddAPQSupport(object extensions, int version, string sha256Hash)
    {
        Dictionary<string, object> resultMap;

        if (extensions == null)
        {
            resultMap = new Dictionary<string, object>();
        }
        else
        {
            try
            {
                JsonElement jsonElement;

                if (extensions is string jsonString)
                {
                    jsonElement = JsonDocument.Parse(jsonString).RootElement;
                }
                else
                {
                    jsonElement = JsonSerializer.SerializeToElement(extensions);
                }

                resultMap = JsonSerializer.Deserialize<Dictionary<string, object>>(jsonElement.GetRawText());
            }
            catch (Exception)
            {
                throw new ArgumentException("Invalid 'extensions' input. It must be a serializable map or a valid JSON string.");
            }
        }

        resultMap["version"] = version;
        resultMap["sha256Hash"] = sha256Hash;

        return resultMap;
    }
}

@Shane32
Copy link
Member

Shane32 commented Apr 25, 2023

It still doesn't deserialize directly from the JsonElement. Just deleting .GetRawText() would give me the answer I was looking for. That is a relatively new feature, not existing in .NET prior to version 6, so probably the ChatGPT AI doesn't know about it since it's knowledge cutoff is 2021 I think.

https://learn.microsoft.com/en-us/dotnet/api/system.text.json.jsonserializer.deserialize?view=net-7.0#system-text-json-jsonserializer-deserialize-1(system-text-json-jsonelement-system-text-json-jsonserializeroptions)

@Shane32
Copy link
Member

Shane32 commented Apr 25, 2023

@rose-a If I remember right, this library supports multiple serializers, so while this answer works for me, may not be the best answer here. It's just an idea.

@rose-a
Copy link
Collaborator

rose-a commented Apr 25, 2023

mh... while this solution would propably work its really hacky in my opinion and it results in a Dictionary<string, object?> anyway.

So if someone puts something into the extensions object which is not serializable to a map it explodes at runtime instead of during or before compilation. (It propably would explode on the server side, anyway, at least with GraphQL.NET, as that uses an Inputs object which is a ReadOnlyDictionary<string, object?>)

@Shane32
Copy link
Member

Shane32 commented Apr 25, 2023

Yeah, no problem. Just FYI, deserializing to Dictionary<string, object> results in the child elements being JsonElement instances. So there is no unnecessary deserialization and reserialization of child nodes. And of course if APQ isn't used then it's not necessary.

@mikocot
Copy link
Contributor

mikocot commented Apr 25, 2023

Problem with extensions is in design. What if someone set request.Extensions = new Person() before passing request into client? How to deal with arbitrary initialized object? How to add new data into it? Ping @rose-a , please comment.

other option is to make it a base type (with only persisted query as property), that one may inherit from, and add additional properties. It's not as flexible as anonymous objecta as many currently probably use but a bit cleaner than making it a dictionary. In any case that seems to be a breaking change in the API.

That would leave option to control the serialziation process and options and use whatever custom serialization (although we'd need to handle serialziation of those base properties for at least two default serializers)

src/GraphQL.Client/Hash.cs Outdated Show resolved Hide resolved
src/GraphQL.Client/Hash.cs Outdated Show resolved Hide resolved
@@ -19,6 +19,13 @@ public GraphQLHttpResponse(GraphQLResponse<T> response, HttpResponseHeaders resp
public HttpStatusCode StatusCode { get; set; }
}

public interface IGraphQLHttpResponse : IGraphQLResponse
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This interface is needed to pass non-generic instance of response into GraphQLHttpClientOptions.DisableAPQ.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you should put the content in there, too... then people can evaluate plaintext error messages...

Always having the raw content in there might be beneficial for general debugging, too, cause then people could cast their responses to IGraphQLHttpResponse and get the raw body of the response...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then I would put entire HttpResponseMessage instead of pulling discrete properties from it. Also note that content may be already unaccessable since stream was read in SendHttpRequestAsync.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#559 , not a goal for this PR

Copy link
Collaborator

@rose-a rose-a left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sungam3r I picked up your work and made the following amendments:

  • Refactor GraphQLHttpClient to allow usage of APQ via websocket connection, too
  • Move hash/extension generation into GraphQL.Primitives and create a GraphQLQuery class which allows to declare a reusable query string with the hash computed only once during object creation.
  • Add basic tests

@Shane32 @mikocot @sungam3r please review, I'd especially like your opinion on the current handling of the hash value in the GraphQLRequest class

The current implementation does not pre-compute the hash if it is created using the constructor accepting a string parameter, and it clears a hash taken from a GraphQLQuery constructor argument when setting the Query property.

This way there is no additional overhead to compute hashes in case APQ is not used, but if someone reuses the GraphQLRequest instance it would always be re-computed before sending. Alternatively we could compute the hash in the setter of the Query property, but that would then also happen if APQ is not used at all.

@rose-a rose-a requested review from mikocot and Shane32 April 25, 2024 07:55
Copy link
Member

@Shane32 Shane32 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here's a few comments

src/GraphQL.Primitives/GraphQLRequest.cs Outdated Show resolved Hide resolved
src/GraphQL.Primitives/GraphQLRequest.cs Outdated Show resolved Hide resolved
src/GraphQL.Primitives/GraphQLQuery.cs Show resolved Hide resolved
src/GraphQL.Primitives/GraphQL.Primitives.csproj Outdated Show resolved Hide resolved
Comment on lines +15 to +16
byte[]? inputBytes = ArrayPool<byte>.Shared.Rent(expected);
int written = Encoding.UTF8.GetBytes(query, 0, query.Length, inputBytes, 0);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be rewritten to use Span / stackalloc also.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you please elaborate? 😉

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I saw that stackalloc was used on lines 21-27. Stackalloc could be used for the call to GetBytes as well. This is faster than ArrayPool.

https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/stackalloc

The easy recommended pattern is to write code like this:

int length = 1000;
Span<byte> buffer = length <= 1024 ? stackalloc byte[length] : new byte[length];

This assumes that typically the buffer length is relatively small, and in such cases, is ideal. It also includes a fallback for large buffer requirements; but then you'd probably lose the ArrayPool (?). And if it was known that the query would commonly be larger than you'd want to allocate on the stack, then maybe the cost of the heap allocation for the long queries might be worse than the benefits from stackalloc. Or you'd have to write a separate if block for the ArrayPool path vs not. I'm not really sure; maybe better off leaving it as-is.

Another possible optimization would be to guess at the length, rather than using GetByteCount. You'd have to write a fallback routine in case the encoded string length was longer than the preallocated buffer though, and you might need to use the underlying Encoder rather than the static method.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I'll leave it as it is. Using Span<byte> would require separate code for old frameworks again, and the best optimization is using the GraphQLQuery class to compute the hash only once... but anyway, thanks for the explanation!

@rose-a rose-a merged commit 6236c9b into master May 21, 2024
2 checks passed
@rose-a rose-a deleted the apq branch May 21, 2024 06:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Built-in support for APQ (Persisted Queries)
4 participants